Skip to content

Plugins-only decoding: drop native parsers, bundle-reference plugins, deletable imports, root-package SDK#24

Merged
colemancda merged 10 commits into
masterfrom
feature/plugins-only
Jul 23, 2026
Merged

Plugins-only decoding: drop native parsers, bundle-reference plugins, deletable imports, root-package SDK#24
colemancda merged 10 commits into
masterfrom
feature/plugins-only

Conversation

@colemancda

Copy link
Copy Markdown
Member

Four related changes to the plugin system, all iOS-focused.

Verified end to end: clean swift build + swift test (38 tests), xcodebuild archive (13 .wasm
plugins bundled), and the app run in the simulator — see the runtime note below.

1. No built-in decoders — plugins only

NativeParsers.swift (the native iBeacon and well-known-characteristic parsers) is deleted, and
PluginManager no longer seeds the registry with them. All decoding now goes through WASM plugins.
Coverage is unchanged: the bundled ibeacon.wasm and the GATT group plugins already cover what the
native parsers did. Tests that used the native parsers as an oracle now assert against hand-computed
values instead.

2. Bundled plugins referenced from the bundle, not copied to Documents

Bundled plugins load directly from Bundle.module and are no longer installed into
Documents/Plugins. Only user-imported plugins live on disk now. PluginDirectory loses the
bundled-install machinery (install record, first-launch/refresh/idempotence logic) and keeps just
import, list, and delete.

3. Imported plugins can be deleted

PluginState gains isRemovable (true only for imported plugins). Bundled plugins are read-only in
the app bundle — they can be disabled but not deleted; imported plugins can be deleted, removing them
from Documents/Plugins. The Plugins screen's swipe-to-delete now applies only to imported rows.

4. BLEPluginSDK is a product of the root package

BLEPluginSDK is now a .library product of the root bluetooth-explorer package, so an external
plugin author can depend on this repository and import BLEPluginSDK. It is not a dependency of the
app, so the app and the iOS archive never build it. Sources are shared with the standalone
PluginSDK/BLEPluginSDK package the in-repo examples use.

Caveat, documented in PluginSDK/README.md: consuming the root package this way does not resolve
yet. The app's AndroidSwiftUI dependency uses a local sub-package, which SwiftPM forbids in a
branch-pinned dependency graph, so any transitive consumer of the root package fails to resolve
(the app builds only because it is the root). Until AndroidSwiftUI stops using a local sub-package,
authors build against the standalone PluginSDK/BLEPluginSDK package — which is what the examples do.
The product is in place and becomes usable the moment that upstream change lands.

Runtime verification

Removing the native parsers and referencing bundled plugins from the bundle were confirmed together
on device: the simulator's mock iBeacon advertisement still decodes (UUID / major / minor / RSSI) —
which can now only be the bundled ibeacon.wasm plugin running under the interpreter — while
Documents/Plugins is empty, proving nothing was copied out of the bundle.

Tests

38 pass (down from 44: the native-parser suite and the two wasm-vs-native parity tests are gone; a
routing-stub test and imported-store import/delete tests are added).

@colemancda
colemancda merged commit 384e6e8 into master Jul 23, 2026
1 of 2 checks passed
@colemancda

Copy link
Copy Markdown
Member Author

Full GATT Client (Central) API coverage

Audited the app against the 16 members of CentralManager. Three were never exercised — they existed only as MockCentral protocol conformance, with no Store method and no UI:

API Was Now
maximumTransmissionUnit(for:) never called read on connect, shown in a Connection section
rssi(for:) never called read on connect, shown as Signal Strength
disconnectAll() never called Disconnect All button, shown only when something is connected

rssi(for:) was a genuine gap despite the list already showing an RSSI — that value comes from advertisement scan data, not from reading the connected peripheral. All 16 members are now reachable from Store.

This surfaced a real crash

Wiring rssi(for:) immediately crashed the app. MockCentral did:

return .init(rawValue: 127)!

RSSI.init?(rawValue:) only accepts -127...20 dBm, so that force-unwrap was always nil — a guaranteed trap that survived only because nothing ever called the method. Fixed to return an in-range value and to throw .disconnected when not connected, matching the sibling MTU method.

Also fixed a race: readConnectionInfo() was gated on isConnected, which is refreshed asynchronously and so still reads false right after connect() returns — leaving both values blank on first load. The central already throws .disconnected, which is caught.

Tests

New BluetoothExplorerModelTests target, 4 tests covering MTU, RSSI (including the range regression), disconnect-required behavior, and disconnectAll. Suite is now 42 passing.

Verified on device

Connected to a peripheral in the simulator: MTU 23 and Signal Strength -60 dBm populate on first load, Refresh no longer crashes, and Disconnect All appears while connected and disappears after tapping it. iOS archive succeeds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant